FX Forward Curve¶
Objective: Retrieve and analyze complete FX forward curves (multiple tenors), with smart data caching
Tenors: 1M, 2M, 3M, 6M, 12M
Workflow:
- Load configuration and set parameters
- Fetch forward curve using
ForwardCurve.fetch()- 2.1. Align base currency across spot and forward points
- 2.2. Convert into forward curve with forward scale
- 2.3. Due to data limitation, tenors in days are hard coded instead of fetching
- Analyze term structure and carry opportunities using class methods
- Export results
1. Setup & Dependencies¶
import os
import sys
from pathlib import Path
from datetime import datetime, timedelta, date
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# Configure Plotly for proper HTML export
import plotly.io as pio
pio.renderers.default = "notebook"
# Ensure Plotly plots include JavaScript in the notebook output
import plotly.offline as pyo
pyo.init_notebook_mode(connected=True)
# Add parent directory to path so we can import src package
sys.path.insert(0, str(Path.cwd().parent))
# Import forward curve module from src package - OOP design
from src.forward_curve import (
load_forward_curve_config,
ForwardCurve, # Main OOP class
# FORWARD_TENORS, # in days 30, 60, 91....
# For appendix (manual step-by-step)
)
# Display settings
pd.set_option('display.max_columns', None)
pd.set_option('display.float_format', '{:.6f}'.format)
sns.set_style('whitegrid')
plt.rcParams['figure.figsize'] = (14, 8)
print("Dependencies loaded")
print("Plotly configured for HTML export")
Dependencies loaded Plotly configured for HTML export
2. Configuration¶
# Load forward curve ticker configuration
config_file = Path.cwd().parent / 'data' / 'raw' / 'forward_curve_tickers.csv'
config = load_forward_curve_config(config_file)
print(f"Loaded configuration for {len(config)} currencies:")
for ccy in config:
print(f" {ccy}: {config[ccy]['spot']}")
# Date range
START_DATE = datetime(2015, 1, 1, 0, 0, 0)
END_DATE = datetime(2025, 11, 15, 0, 0, 0)
print(f"\nDate range: {START_DATE.date()} to {END_DATE.date()}")
Loaded configuration for 16 currencies: USDAED: USDAED Curncy AUDUSD: AUDUSD Curncy USDBRL: USDBRL Curncy USDCAD: USDCAD Curncy USDCHF: USDCHF Curncy USDCNH: USDCNH Curncy USDCNY: USDCNY Curncy EURUSD: EURUSD Curncy GBPUSD: GBPUSD Curncy USDHKD: USDHKD Curncy USDINR: USDINR Curncy USDJPY: USDJPY Curncy USDKRW: USDKRW Curncy USDSAR: USDSAR Curncy USDTWD: USDTWD Curncy USDZAR: USDZAR Curncy Date range: 2015-01-01 to 2025-11-15
3. Fetch Forward Curve¶
Use ForwardCurve.fetch() to retrieve and build the forward curve with validation and FWD_SCALE handling.
# Fetch forward curve for a single currency using OOP interface
currency_pair = 'GBPUSD'
curve = ForwardCurve.fetch(
config=config,
currency=currency_pair,
start_date=START_DATE,
end_date=END_DATE,
periodicity='D', #
validate=True,
verbose=True
)
print(f"\n{curve}")
print(f"Available tenors: {curve.tenors}")
curve.data.tail()
================================================================================ FORWARD CURVE RETRIEVAL: GBPUSD ================================================================================ [VALIDATING] GBPUSD forward curve tickers... [CACHE HIT] bdp: 6 tickers, fields=['QUOTATION_BASE_CURRENCY'] PASS: All 6 tickers have base currency: GBP [CACHE HIT] bdp: 5 tickers, fields=['SECURITY_TYP'] PASS: All 5 forward tickers have type: FORWARD [FWD_SCALE] Retrieving scales for GBPUSD... [CACHE HIT] bdp: 5 tickers, fields=['FWD_SCALE'] 1M (GBP1M Curncy): FWD_SCALE = 4.0 2M (GBP2M Curncy): FWD_SCALE = 4.0 3M (GBP3M Curncy): FWD_SCALE = 4.0 6M (GBP6M Curncy): FWD_SCALE = 4.0 12M (GBP12M Curncy): FWD_SCALE = 4.0 [FETCHING] GBPUSD forward curve data... Period: 2015-01-01 to 2025-11-15 Periodicity: D [CACHE HIT] bdh: GBPUSD Curncy PX_LAST D Spot (GBPUSD Curncy): 2837 observations [CACHE HIT] bdh: GBP1M Curncy PX_LAST D 1M (GBP1M Curncy): 2837 observations [CACHE HIT] bdh: GBP2M Curncy PX_LAST D 2M (GBP2M Curncy): 2837 observations [CACHE HIT] bdh: GBP3M Curncy PX_LAST D 3M (GBP3M Curncy): 2837 observations [CACHE HIT] bdh: GBP6M Curncy PX_LAST D 6M (GBP6M Curncy): 2837 observations [CACHE HIT] bdh: GBP12M Curncy PX_LAST D 12M (GBP12M Curncy): 2837 observations [CALCULATING] Building forward curve... Forward curve built: 2837 observations Columns: ['Date', 'Spot', 'FWD_1M_Points', 'FWD_1M_Rate', 'FWD_2M_Points', 'FWD_2M_Rate', 'FWD_3M_Points', 'FWD_3M_Rate', 'FWD_6M_Points', 'FWD_6M_Rate', 'FWD_12M_Points', 'FWD_12M_Rate'] ForwardCurve(GBPUSD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) Available tenors: ['1M', '2M', '3M', '6M', '12M']
| Date | Spot | FWD_1M_Points | FWD_1M_Rate | FWD_2M_Points | FWD_2M_Rate | FWD_3M_Points | FWD_3M_Rate | FWD_6M_Points | FWD_6M_Rate | FWD_12M_Points | FWD_12M_Rate | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2832 | 2025-11-10 | 1.317500 | -0.400000 | 1.317460 | -0.940000 | 1.317406 | -1.410000 | 1.317359 | -2.760000 | 1.317224 | -12.730000 | 1.316227 |
| 2833 | 2025-11-11 | 1.315000 | -0.510000 | 1.314949 | -0.630000 | 1.314937 | -0.760000 | 1.314924 | -0.800000 | 1.314920 | -7.300000 | 1.314270 |
| 2834 | 2025-11-12 | 1.313300 | -0.330000 | 1.313267 | -0.250000 | 1.313275 | -0.170000 | 1.313283 | 0.460000 | 1.313346 | -5.050000 | 1.312795 |
| 2835 | 2025-11-13 | 1.319200 | -0.380000 | 1.319162 | 0.160000 | 1.319216 | 0.380000 | 1.319238 | 1.790000 | 1.319379 | -3.800000 | 1.318820 |
| 2836 | 2025-11-14 | 1.317100 | -0.470000 | 1.317053 | 0.060000 | 1.317106 | 0.210000 | 1.317121 | 0.600000 | 1.317160 | -9.100000 | 1.316190 |
4. Analyze Term Structure¶
# Plot term structure using class method
fig = curve.plot_term_structure()
fig.show()
5. Historical Carry Analysis¶
# Calculate carry metrics using class method
carry_metrics = curve.carry_metrics()
carry_metrics.tail()
| Date | Spot | 1M_Carry_Ann_% | 1M_Premium_bps | 1M_Quantile | 2M_Carry_Ann_% | 2M_Premium_bps | 2M_Quantile | 3M_Carry_Ann_% | 3M_Premium_bps | 3M_Quantile | 6M_Carry_Ann_% | 6M_Premium_bps | 6M_Quantile | 12M_Carry_Ann_% | 12M_Premium_bps | 12M_Quantile | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2832 | 2025-11-10 | 1.317500 | -0.036939 | -3.693865 | 15.932323 | -0.043403 | -4.340291 | 15.368347 | -0.042926 | -4.292595 | 15.121607 | -0.042013 | -4.201264 | 18.117730 | -0.096622 | -9.662239 | 19.844907 |
| 2833 | 2025-11-11 | 1.315000 | -0.047186 | -4.718631 | 15.403595 | -0.029144 | -2.914449 | 15.897074 | -0.023181 | -2.318138 | 15.967571 | -0.012201 | -1.220073 | 19.351428 | -0.055513 | -5.551331 | 22.030314 |
| 2834 | 2025-11-12 | 1.313300 | -0.030572 | -3.057184 | 16.496299 | -0.011580 | -1.158024 | 16.602044 | -0.005192 | -0.519202 | 16.743038 | 0.007024 | 0.702450 | 20.267889 | -0.038453 | -3.845275 | 23.052520 |
| 2835 | 2025-11-13 | 1.319200 | -0.035046 | -3.504649 | 16.214311 | 0.007378 | 0.737821 | 17.166020 | 0.011554 | 1.155379 | 17.589002 | 0.027212 | 2.721221 | 21.713077 | -0.028805 | -2.880534 | 23.440254 |
| 2836 | 2025-11-14 | 1.317100 | -0.043416 | -4.341609 | 15.579838 | 0.002771 | 0.277124 | 16.989778 | 0.006395 | 0.639517 | 17.412760 | 0.009136 | 0.913596 | 20.479380 | -0.069091 | -6.909119 | 21.149101 |
# Plot historical forward premium using class method
fig = curve.plot_carry_history()
fig.show()
fig = curve.plot_spot_and_forwards()
fig.show()
6. Multi-Currency Comparison¶
# Analyze multiple currencies using ForwardCurve class
currencies_to_analyze = config.keys() # ['EURUSD', 'GBPUSD', 'USDJPY', 'AUDUSD', 'USDCAD', 'USDCHF', 'USDTWD']
curves = {}
for ccy in currencies_to_analyze:
try:
curves[ccy] = ForwardCurve.fetch(
config=config,
currency=ccy,
start_date=START_DATE,
end_date=END_DATE,
periodicity='D',
# verbose=True
)
except Exception as e:
print(f"Error fetching {ccy}: {e}")
print(f"\n{'='*60}")
print(f"SUMMARY: Successfully fetched {len(curves)}/{len(currencies_to_analyze)} currencies")
print(f"{'='*60}")
for ccy, c in curves.items():
print(f" {c}")
============================================================ SUMMARY: Successfully fetched 16/16 currencies ============================================================ ForwardCurve(USDAED, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(AUDUSD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(USDBRL, 2015-01-01 to 2025-11-15, n=2832, periodicity=D) ForwardCurve(USDCAD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(USDCHF, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(USDCNH, 2015-01-01 to 2025-11-15, n=2836, periodicity=D) ForwardCurve(USDCNY, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(EURUSD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(GBPUSD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(USDHKD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(USDINR, 2015-01-01 to 2025-11-15, n=2834, periodicity=D) ForwardCurve(USDJPY, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(USDKRW, 2015-01-01 to 2025-11-15, n=2832, periodicity=D) ForwardCurve(USDSAR, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(USDTWD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D) ForwardCurve(USDZAR, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
# Plot term structure for each currency pair (Interactive Plotly version)
import plotly.graph_objects as go
from plotly.subplots import make_subplots
# Create subplot grid with improved spacing
n_currencies = len(curves)
n_cols = 2
n_rows = (n_currencies + n_cols - 1) // n_cols # Ceiling division
fig = make_subplots(
rows=n_rows, cols=n_cols,
subplot_titles=[f'<b>{ccy}</b>' for ccy in curves.keys()],
vertical_spacing=0.08, # Reduced for tighter layout
horizontal_spacing=0.12 # Increased for better separation
)
for idx, (ccy, c) in enumerate(curves.items()):
row = idx // n_cols + 1
col = idx % n_cols + 1
ts = c.term_structure()
ts_fwd = ts[ts['Tenor'] != 'Spot'].copy()
# Calculate individual y-axis range for this subplot
y_max = ts_fwd['Premium_Ann_bps'].max()
y_min = ts_fwd['Premium_Ann_bps'].min()
y_range = y_max - y_min
# Add 25% padding to ensure labels are visible
y_padding = y_range * 0.25 if y_range > 0 else 50 # Minimum padding of 50 bps
y_axis_max = y_max + y_padding
y_axis_min = y_min - y_padding
# Color based on premium sign with better colors
colors = ['#27ae60' if x >= 0 else '#e74c3c' for x in ts_fwd['Premium_Ann_bps']]
# Add bar trace with improved styling
fig.add_trace(
go.Bar(
x=ts_fwd['Tenor'],
y=ts_fwd['Premium_Ann_bps'],
marker=dict(
color=colors,
line=dict(color='rgba(0,0,0,0.3)', width=1.5),
opacity=0.85
),
text=[f'{val:.0f}' for val in ts_fwd['Premium_Ann_bps']],
textposition='outside',
textfont=dict(size=11, color='black', family='Arial'),
hovertemplate='<b>%{x}</b><br>' +
'Premium: %{y:.1f} bps<br>' +
f'Spot: {c.spot.iloc[-1]:.4f}<br>' +
'<extra></extra>',
showlegend=False,
width=0.6 # Bar width
),
row=row, col=col
)
# Add zero line with better styling
fig.add_hline(
y=0,
line=dict(color='rgba(0,0,0,0.4)', width=1.5, dash='solid'),
row=row, col=col
)
# Update axes for this subplot with individual range and better styling
fig.update_xaxes(
title_text='',
row=row, col=col,
tickfont=dict(size=11, family='Arial'),
showline=True,
linewidth=1,
linecolor='rgba(0,0,0,0.2)'
)
fig.update_yaxes(
title_text='Premium (bps p.a.)',
title_font=dict(size=10, family='Arial'),
row=row, col=col,
range=[y_axis_min, y_axis_max],
tickfont=dict(size=10, family='Arial'),
showline=True,
linewidth=1,
linecolor='rgba(0,0,0,0.2)'
)
# Get the latest date from the first curve
latest_date = list(curves.values())[0].dates.iloc[-1]
date_str = latest_date.strftime('%Y-%m-%d')
# Update overall layout with improved aesthetics
fig.update_layout(
title=dict(
text=f'<b>Forward Premium Term Structure Comparison</b><br><sub>As of {date_str}</sub>',
font=dict(size=20, color='#2c3e50', family='Arial'),
x=0.5,
xanchor='center',
y=0.98,
yanchor='top'
),
height=320 * n_rows, # Increased from 250 for better visibility
width=1400, # Set explicit width for better proportions
showlegend=False,
hovermode='closest',
plot_bgcolor='white',
paper_bgcolor='#fafafa',
margin=dict(l=80, r=80, t=120, b=60),
font=dict(family='Arial', size=11)
)
# Update subplot titles styling
for annotation in fig['layout']['annotations']:
annotation['font'] = dict(size=13, family='Arial', color='#2c3e50')
# Update all y-axes to show gridlines with better styling
fig.update_yaxes(
showgrid=True,
gridcolor='rgba(200,200,200,0.3)',
gridwidth=0.5,
zeroline=False
)
fig.update_xaxes(
showgrid=False
)
fig.show()
# Plot historical carry for each currency pair (Interactive Plotly version)
import plotly.graph_objects as go
from plotly.subplots import make_subplots
# Create subplot grid
n_currencies = len(curves)
n_cols = 2
n_rows = (n_currencies + n_cols - 1) // n_cols
fig = make_subplots(
rows=n_rows, cols=n_cols,
subplot_titles=[f'{ccy} Historical Forward Premium' for ccy in curves.keys()],
vertical_spacing=0.10,
horizontal_spacing=0.08
)
# Color palette for tenors
tenor_colors = {'1M': '#3498db', '2M': '#9b59b6', '3M': '#2ecc71', '6M': '#f39c12', '12M': '#e74c3c'}
tenors_to_plot = ['1M', '2M', '3M', '6M', '12M'] # Focus on key tenors
for idx, (ccy, c) in enumerate(curves.items()):
row = idx // n_cols + 1
col = idx % n_cols + 1
# Get carry metrics
carry = c.carry_metrics()
# Plot each tenor
for tenor in tenors_to_plot:
col_name = f'{tenor}_Carry_Ann_%'
if col_name in carry.columns:
fig.add_trace(
go.Scatter(
x=carry['Date'],
y=carry[col_name],
mode='lines',
name=tenor,
line=dict(color=tenor_colors[tenor], width=2),
hovertemplate='<b>%{x|%Y-%m-%d}</b><br>' +
f'{tenor}: %{{y:.2f}}%<br>' +
'<extra></extra>',
legendgroup=tenor,
showlegend=(idx == 0) # Only show legend for first subplot
),
row=row, col=col
)
# Add zero line
fig.add_hline(y=0, line=dict(color='black', width=1, dash='dash'),
row=row, col=col, opacity=0.5)
# Update axes
fig.update_xaxes(title_text='Date', row=row, col=col, showgrid=True)
fig.update_yaxes(title_text='Annualized Forward Premium (%)', row=row, col=col, showgrid=True)
# Update overall layout
fig.update_layout(
title=dict(
text='Historical Carry/Forward Premium Analysis (10Y)',
font=dict(size=18, color='black'),
x=0.5,
xanchor='center'
),
height=400 * n_rows,
hovermode='x unified',
plot_bgcolor='white',
paper_bgcolor='white',
legend=dict(
orientation='h',
yanchor='bottom',
y=1.02,
xanchor='center',
x=0.5,
font=dict(size=12)
)
)
# Update all axes for better appearance
fig.update_xaxes(showgrid=True, gridcolor='lightgray', gridwidth=0.5)
fig.update_yaxes(showgrid=True, gridcolor='lightgray', gridwidth=0.5)
fig.show()